import anyTest from 'ava'
import type { ExecutionContext } from 'ava'
import type { Macro, TestFn } from 'ava'
import { importJWK, type JWK } from 'jose'
import * as undici from 'undici'
import { inspect } from 'node:util'

export const test = anyTest as TestFn<{ instance: Test }>

import { getScope, makePublicJwks } from './ava.config.js'
import * as client from '../src/index.js'
import {
  createTestFromPlan,
  waitForState,
  getTestExposed,
  type ModulePrescription,
  type Plan,
  type Test,
} from './api.js'

const conformance = JSON.parse(process.env.CONFORMANCE!)

const configuration: {
  alias: string
  client: {
    client_id: string
    client_secret?: string
    redirect_uri: string
    use_mtls_endpoint_aliases: boolean
    jwks: {
      keys: Array<JWK & { kid: string }>
    }
  }
  client2: {
    jwks: {
      keys: Array<JWK & { kid: string }>
    }
  }
} = conformance.configuration

const ALG = conformance.ALG as string
export const plan: Plan = conformance.plan
export const variant: Record<string, string> = conformance.variant
export const mtls: { key: string; cert: string } = conformance.mtls || {}

let prefix = ''

switch (plan.name) {
  case 'fapi1-advanced-final-client-test-plan':
  case 'fapi2-security-profile-final-client-test-plan':
    prefix = plan.name.slice(0, -4)
    break
  case 'fapi2-message-signing-final-client-test-plan':
    prefix = 'fapi2-security-profile-final-client-test-'
    break
  case 'oidcc-client-test-plan':
  case 'oidcc-client-basic-certification-test-plan':
  case 'oidcc-client-implicit-certification-test-plan':
  case 'oidcc-client-hybrid-certification-test-plan':
    prefix = 'oidcc-client-test-'
    break
  default:
    throw new Error()
}

async function importPrivateKey(alg: string, jwk: JWK) {
  const key = await importJWK(jwk, alg)
  if (!('type' in key)) {
    throw new Error()
  }
  return key
}

export function modules(metaUrl: string): ModulePrescription[] {
  const name = metaUrl.split('/').reverse()[0].replace('.ts', '')
  return conformance.plan.modules.filter((x: ModulePrescription) => {
    switch (x.variant?.response_type) {
      case 'id_token token':
      case 'code token':
      case 'code id_token token':
        return false
    }

    return (
      x.testModule ===
      (name === prefix.slice(0, -1) ? name : `${prefix}${name}`)
    )
  })
}

function usesJarm(variant: Record<string, string>) {
  return variant.fapi_response_mode === 'jarm'
}

function usesDpop(variant: Record<string, string>) {
  return variant.sender_constrain === 'dpop'
}

function usesPar(plan: Plan) {
  return (
    plan.name.startsWith('fapi2') ||
    variant.fapi_auth_request_method === 'pushed'
  )
}

export function nonRepudiation(plan: Plan) {
  return (
    plan.name.startsWith('fapi2-message-signing') ||
    plan.name.startsWith('fapi1')
  )
}

function usesRequestObject(planName: string, variant: Record<string, string>) {
  if (planName.startsWith('fapi1')) {
    return true
  }

  if (planName.startsWith('fapi2-message-signing')) {
    return true
  }

  if (variant.request_type === 'request_object') {
    return true
  }

  return false
}

function requiresNonce(planName: string, variant: Record<string, string>) {
  return (
    responseType(planName, variant).includes('id_token') ||
    (planName.startsWith('fapi1') && getScope(variant).includes('openid'))
  )
}

function requiresState(planName: string, variant: Record<string, string>) {
  return planName.startsWith('fapi1') && !getScope(variant).includes('openid')
}

function responseType(planName: string, variant: Record<string, string>) {
  if (variant.response_type) {
    return variant.response_type
  }

  if (!planName.startsWith('fapi1')) {
    return 'code'
  }

  return variant.fapi_response_mode === 'jarm' ? 'code' : 'code id_token'
}

function dcr(variant: Record<string, string>) {
  return variant.client_registration === 'dynamic_client'
}

export interface MacroOptions {
  useNonce?: boolean
  useState?: boolean
}

export const flow = (options?: MacroOptions) => {
  return test.macro({
    async exec(t, module: ModulePrescription) {
      t.timeout(15000)

      const instance = await createTestFromPlan(plan, module)
      t.context.instance = instance

      t.log('Test ID', instance.id)
      t.log('Test Name', instance.name)

      const variant: Record<string, string> = {
        ...conformance.variant,
        ...module.variant,
      }
      t.log('variant', variant)

      const { issuer: issuerIdentifier, accounts_endpoint } =
        await getTestExposed(instance)

      if (!issuerIdentifier) {
        throw new Error()
      }

      const issuer = new URL(issuerIdentifier)

      const response_type = responseType(plan.name, variant)
      const metadata: client.ClientMetadata = makePublicJwks({
        client_id: dcr(variant) ? undefined : configuration.client.client_id,
        client_secret: dcr(variant)
          ? undefined
          : configuration.client.client_secret,
        use_mtls_endpoint_aliases:
          configuration.client.use_mtls_endpoint_aliases,
        jwks: configuration.client.jwks,
        redirect_uris: [configuration.client.redirect_uri],
        response_types: [response_type],
        grant_types:
          response_type === 'code'
            ? ['authorization_code']
            : ['authorization_code', 'implicit'],
      })

      switch (variant.client_auth_type) {
        case 'mtls':
          metadata.token_endpoint_auth_method = 'self_signed_tls_client_auth'
          break
        case 'none':
        case 'private_key_jwt':
        case 'client_secret_basic':
        case 'client_secret_post':
          metadata.token_endpoint_auth_method = variant.client_auth_type
          break
      }

      if (instance.name.includes('client-secret-basic')) {
        metadata.token_endpoint_auth_method = 'client_secret_basic'
      }

      // @ts-expect-error
      const mtlsFetch: typeof fetch = async (
        ...args: Parameters<typeof fetch>
      ) => {
        // @ts-expect-error
        let response = await undici.fetch(args[0], {
          ...args[1],
          dispatcher: new undici.Agent({
            connect: {
              key: mtls.key,
              cert: mtls.cert,
            },
          }),
        })

        if (dcr(variant)) {
          // TODO: remove when https://gitlab.com/openid/conformance-suite/-/issues/1503 is fixed
          if (response.ok && (args[0] as string).endsWith('/register')) {
            const body = (await response.json()) as any
            if (body.client_secret) {
              body.client_secret_expires_at = 0
            }
            // @ts-expect-error
            response = new Response(JSON.stringify(body), response)
          }
        }

        return response
      }

      let dcrFetch: typeof fetch | undefined
      if (dcr(variant)) {
        // TODO: remove when https://gitlab.com/openid/conformance-suite/-/issues/1503 is fixed
        dcrFetch = async (...args: Parameters<typeof fetch>) => {
          let response = await fetch(...args)
          if (response.ok && (args[0] as string).endsWith('/register')) {
            const body = await response.json()
            if (body.client_secret) {
              body.client_secret_expires_at = 0
            }
            response = new Response(JSON.stringify(body), response)
          }
          return response
        }
      }

      const mtlsAuth = variant.client_auth_type === 'mtls'
      const mtlsConstrain =
        plan.name.startsWith('fapi1') || variant.sender_constrain === 'mtls'

      const execute: Array<(config: client.Configuration) => void> = []

      if (nonRepudiation(plan)) {
        execute.push(client.enableNonRepudiationChecks)
      }

      if (usesJarm(variant)) {
        execute.push(client.useJwtResponseMode)
      }

      if (response_type === 'code id_token') {
        execute.push(client.useCodeIdTokenResponseType)
        if (plan.name.startsWith('fapi1')) {
          execute.push(client.enableDetachedSignatureResponseChecks)
        }
      }

      if (response_type === 'id_token') {
        execute.push(client.useIdTokenResponseType)
      }

      const [jwk] = configuration.client.jwks.keys
      const clientPrivateKey = {
        kid: jwk.kid,
        key: await importPrivateKey(ALG, jwk),
      }
      const client_secret = dcr(variant) ? undefined : metadata.client_secret

      let clientAuth: client.ClientAuth | undefined
      if (metadata.token_endpoint_auth_method === 'private_key_jwt') {
        clientAuth = client.PrivateKeyJwt(clientPrivateKey)
      } else if (
        metadata.token_endpoint_auth_method === 'client_secret_basic'
      ) {
        clientAuth = client.ClientSecretBasic(client_secret)
      }

      let config: client.Configuration
      if (dcr(variant)) {
        config = await client.dynamicClientRegistration(
          issuer,
          metadata,
          clientAuth,
          {
            execute,
            // @ts-ignore
            [client.customFetch]:
              mtlsAuth || mtlsConstrain || metadata.use_mtls_endpoint_aliases
                ? mtlsFetch
                : dcr(variant)
                  ? dcrFetch
                  : undefined,
          },
        )
      } else {
        config = await client.discovery(
          issuer,
          configuration.client.client_id,
          metadata,
          clientAuth,
          {
            execute,
            // @ts-ignore
            [client.customFetch]:
              mtlsAuth || mtlsConstrain || metadata.use_mtls_endpoint_aliases
                ? mtlsFetch
                : undefined,
          },
        )
      }

      if (module.testModule.includes('encrypted')) {
        const jwk = configuration.client.jwks.keys[0]
        const key = await importPrivateKey('RSA-OAEP', jwk)
        client.enableDecryptingResponses(config, undefined, {
          key,
          kid: `enc-${jwk.kid}`,
        })
      }

      t.log('AS Metadata discovered for', issuer.href)
      if (dcr(variant)) {
        t.log(
          'Client Metadata registered for',
          config.clientMetadata().client_id,
        )
      }

      const DPoP = usesDpop(variant)
        ? client.getDPoPHandle(config, await client.randomDPoPKeyPair(ALG))
        : undefined

      const code_verifier = client.randomPKCECodeVerifier()
      const code_challenge =
        await client.calculatePKCECodeChallenge(code_verifier)
      const code_challenge_method = 'S256'

      if (
        !config.serverMetadata().supportsPKCE() &&
        !response_type.includes('id_token')
      ) {
        options ||= {}
        options.useState = true
      }

      const scope = getScope(variant)
      let nonce =
        options?.useNonce || requiresNonce(plan.name, variant)
          ? client.randomNonce()
          : undefined
      let state =
        options?.useState || requiresState(plan.name, variant)
          ? client.randomState()
          : undefined

      let params: URLSearchParams = new URLSearchParams()
      if (code_challenge) {
        params.set('code_challenge', code_challenge)
      }
      if (code_challenge_method) {
        params.set('code_challenge_method', code_challenge_method)
      }
      params.set('redirect_uri', configuration.client.redirect_uri)
      params.set('scope', scope)
      if (typeof nonce === 'string') {
        params.set('nonce', nonce)
      }
      if (typeof state === 'string') {
        params.set('state', state)
      }

      if (usesRequestObject(plan.name, variant)) {
        ;({ searchParams: params } = await client.buildAuthorizationUrlWithJAR(
          config,
          params,
          clientPrivateKey,
        ))
      }

      let authorizationUrl: URL

      if (usesPar(plan)) {
        t.log('PAR request with', Object.fromEntries(params.entries()))
        authorizationUrl = await client.buildAuthorizationUrlWithPAR(
          config,
          params,
          {
            DPoP,
          },
        )
        t.log(
          'PAR request_uri',
          authorizationUrl.searchParams.get('request_uri'),
        )
      } else {
        if (params.has('request') && plan.name.startsWith('fapi1')) {
          const plain = client.buildAuthorizationUrl(config, {})
          params.set('response_type', plain.searchParams.get('response_type')!)
          params.set('scope', 'openid')
        }
        authorizationUrl = client.buildAuthorizationUrl(config, params)
      }

      await Promise.allSettled([
        fetch(authorizationUrl.href, { redirect: 'manual' }),
      ])

      t.log(
        'redirect with',
        Object.fromEntries(authorizationUrl.searchParams.entries()),
      )

      const { authorization_endpoint_response_redirect } =
        await getTestExposed(instance)

      if (!authorization_endpoint_response_redirect) {
        throw new Error()
      }

      const currentUrl = new URL(authorization_endpoint_response_redirect)

      t.log('response redirect to', currentUrl.href)

      if (response_type === 'id_token') {
        const response = await client.implicitAuthentication(
          config,
          currentUrl,
          nonce!,
          {
            expectedState: state,
          },
        )

        t.log('validated ID Token Claims Set', {
          ...response,
        })
      } else {
        const response = await client.authorizationCodeGrant(
          config,
          currentUrl,
          {
            expectedNonce: nonce,
            expectedState: state,
            pkceCodeVerifier: code_verifier,
          },
          undefined,
          { DPoP },
        )

        t.log('token endpoint response', { ...response })

        if (
          !plan.name.startsWith('fapi1') &&
          scope.includes('openid') &&
          config.serverMetadata().userinfo_endpoint
        ) {
          // fetch userinfo response
          t.log('fetching', config.serverMetadata().userinfo_endpoint)
          const userinfo = await client.fetchUserInfo(
            config,
            response.access_token,
            response.claims()?.sub!,
            {
              DPoP,
            },
          )
          t.log('userinfo endpoint response', { ...userinfo })
        }

        if (accounts_endpoint) {
          const resource = await client.fetchProtectedResource(
            config,
            response.access_token,
            new URL(accounts_endpoint),
            'GET',
            null,
            undefined,
            { DPoP },
          )

          const result = await resource.text()
          try {
            t.log('accounts endpoint response', JSON.parse(result))
          } catch {
            t.log('accounts endpoint response body', result)
          }
        }
      }

      await waitForState(instance)
      if (module.skipLogTestFinished !== true) {
        t.log('Test Finished')
      }
      t.pass()
    },
    title(providedTitle = '', module: ModulePrescription) {
      if (module.variant) {
        return `${providedTitle}${plan.name} (${plan.id}) - ${module.testModule} - ${JSON.stringify(
          module.variant,
        )}`
      }
      return `${providedTitle}${plan.name} (${plan.id}) - ${module.testModule}`
    },
  })
}

interface ErrorAssertion {
  name: string
  code: string
  message: string | RegExp
}

export type CodeErrorAssertion = Partial<ErrorAssertion> &
  Pick<ErrorAssertion, 'code'>
export type NameErrorAssertion = Partial<ErrorAssertion> &
  Pick<ErrorAssertion, 'name'>
export type MessageErrorAssertion = Partial<ErrorAssertion> &
  Pick<ErrorAssertion, 'message'>

function assertError(
  t: ExecutionContext,
  actual: unknown,
  expected: Partial<ErrorAssertion>,
) {
  if (!(actual instanceof Error)) {
    t.fail('expected and Error instance')
  }

  // @ts-ignore
  if (expected.code) t.is(actual.code, expected.code)
  if (expected.name) t.is(actual.name, expected.name)
  if (expected.message) {
    if (typeof expected.message === 'string') {
      t.is(actual.message, expected.message)
    } else {
      t.regex(actual.message, expected.message)
    }
  }
}

export const rejects = (
  macro: Macro<[module: ModulePrescription], { instance: Test }>,
) => {
  return test.macro({
    async exec(
      t,
      module: ModulePrescription,
      expected: CodeErrorAssertion | MessageErrorAssertion,
      cause?: CodeErrorAssertion | MessageErrorAssertion,
    ) {
      const err = await t.throwsAsync(
        () => macro.exec(t, { ...module, skipLogTestFinished: true }) as any,
      )
      t.log('rejected with', inspect(err, { depth: Infinity }))

      expected.name ||= 'ClientError'
      assertError(t, err, expected)

      if (cause) {
        cause.name ||= 'OperationProcessingError'
        if (!(err.cause instanceof Error)) {
          t.fail('expected and Error instance')
        }
        t.truthy(
          err.cause,
          'expected err to have a [cause] that is an Error instance',
        )

        if (typeof cause !== 'boolean') {
          assertError(t, err.cause, cause)
        }
      }

      await waitForState(t.context.instance)
      t.log('Test Finished')
      t.pass()
    },
    title: macro.title as any,
  })
}

export const skippable = (
  macro: Macro<[module: ModulePrescription], { instance: Test }>,
) => {
  return test.macro({
    async exec(t, module: ModulePrescription) {
      await Promise.allSettled([
        macro.exec(t, { ...module, skipLogTestFinished: true }),
      ])

      await waitForState(t.context.instance, {
        results: new Set(['SKIPPED', 'PASSED']),
      })
      t.log('Test Finished')
      t.pass()
    },
    title: macro.title as any,
  })
}
